package com.bungarus.busi;

import com.bungarus.Access;
import com.bungarus.codec.TokenGenerator;
import com.bungarus.codec.protobuf.MessageProtobuf;
import com.bungarus.event.AuthSucceededEvent;
import com.bungarus.model.*;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.SocketOptions;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.async.RedisAsyncCommands;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import org.apache.log4j.Logger;
import org.springframework.util.StringUtils;
import sun.security.rsa.RSAPrivateCrtKeyImpl;

import java.security.InvalidKeyException;
import java.security.PrivateKey;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Calendar;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

/**
 *
 * Created by tang on 2018/11/11.
 */
public class Authentication extends AccessBusiProcessing{
    private static Logger logger = Logger.getLogger(Authentication.class);
    private static final int AUTH_TIMEOUT = 3000;
    private static final int SESSION_EXPIRATION = 2 * 60 * 60 * 1000;

    private final ChannelHandlerContext channelHandlerContext;
    private final Access access;

    //constructor
    public Authentication(ChannelHandlerContext channelHandlerContext, Access access) {
        this.channelHandlerContext = channelHandlerContext;
        this.access = access;
    }

    @Override
    public void doBusiness(Object o) throws BusiProcessingException {
        MessageProtobuf.Message message = (MessageProtobuf.Message)o;
        MessageProtobuf.Header header = message.getHeader();
        MessageProtobuf.Credential credential = message.getHeader().getCredential();
        String sessionID = header.getSessionId();

        //Basic auth has the highest priority
        if(!StringUtils.isEmpty(credential.getUsername()) && !StringUtils.isEmpty(credential.getPwd())) { //username and password are both provided, do basic auth
            doSomething(() -> {
                Login remoteLogin = AccessBusiProcessing.springContext.getBean(GroupServiceLogin.class);
                Member member = remoteLogin.login(credential.getUsername(), credential.getPwd(), String.valueOf(header.getTenantID()));
                if (null != member && member.getId() > 0) {  //doSomething succeeded
                    //check the validation of device
                    Device device = Device.Builder.newBuilder()
                            .id(message.getHeader().getDev())
                            .user(String.valueOf(member.getId()))
                            .tenant(member.getTenantId())
                            .app(message.getHeader().getApp())
                            .build();
                    try {
                        checkDeviceValidation(device, true);
                        //create and save authenticated token
                        String token = createAndSaveAuthenticatedToken(member);
                        MessageProtobuf.Header newHeader = MessageProtobuf.Header.newBuilder(header)
                                .setToken(token)
                                .build();
                        this.channelHandlerContext.fireUserEventTriggered(
                                new AuthSucceededEvent()
                                        .setMember(member)
                                        .setHeader(newHeader));
                        return true;
                    }
                    catch (AuthenticationInputException.DeviceInvalidException | AuthenticationInputException.DeviceUnregisteredException e) {
                        this.channelHandlerContext.fireExceptionCaught(e);
                        e.printStackTrace();
                        return false;
                    }
                }
                else {
                    this.channelHandlerContext.fireExceptionCaught(new UserNonexistException());
                    return false;
                }
            });
        }
        else if(StringUtils.isEmpty(sessionID) && !StringUtils.isEmpty(header.getToken())){ //if no sessionID, then re-auth, can this type of message(no session) be received here?
            doSomething(() -> {
                checkTokenStorageAndDevice(message);
                return true;
            });
        }
        else if(!StringUtils.isEmpty(sessionID)){
            doSomething(() -> {
                Session session = new Session();
                session.setId(sessionID);
                Channel channel = Access.getSessionContainer().find(session);
                if (null != channel) {
                    session = Access.getSessionContainer().findSession(sessionID);
                    if (System.currentTimeMillis() - session.getCreated() > SESSION_EXPIRATION) { // if session timeout
                        this.channelHandlerContext.fireExceptionCaught(new AuthenticationExpirationException());
                        return false;
                    }
                    this.channelHandlerContext.fireChannelRead(MessageBuilder.cloneMessageWithNewId(access.getIdMaker(), message));
                }
                else {
                    checkTokenStorageAndDevice(message);
                }
                return true;
            });
        }
        else {
            this.channelHandlerContext.fireExceptionCaught(new AuthenticationInputException());
        }
    }

    public void logout(final String tenantId, final String userId) {
        doSomething(() -> {
            UserTokenStorage.getInstance().removeMember(tenantId, userId);
            return true;
        });
    }

    private void checkTokenStorageAndDevice(MessageProtobuf.Message message) {
        UserTokenStorage tokenStorage = UserTokenStorage.getInstance();
        tokenStorage.retrieveMember(String.valueOf(message.getHeader().getTenantID()), message.getHeader().getFrom(), memberMap -> {
            if (null != memberMap && message.getHeader().getToken().equals(memberMap.get("token"))) {
                Member member = new Member();
                member.setId(Long.parseLong(message.getHeader().getFrom()));
                member.setAccountName(memberMap.get("account"));
                member.setName(memberMap.get("name"));
                member.setTenantId(String.valueOf(message.getHeader().getTenantID()));

                Device device = Device.Builder.newBuilder()
                        .id(message.getHeader().getDev())
                        .user(message.getHeader().getFrom())
                        .tenant(member.getTenantId())
                        .app(message.getHeader().getApp())
                        .build();
                try {
                    checkDeviceValidation(device, true);
                    MessageProtobuf.Header newHeader = MessageProtobuf.Header.newBuilder(message.getHeader())
                            .setType(MessageType.AUTH)
                            .build();

                    this.channelHandlerContext.fireUserEventTriggered(
                            new AuthSucceededEvent()
                                    .setMember(member)
                                    .setHeader(newHeader)
                                    .setReAuth(true));
                    return member;
                }
                catch (AuthenticationInputException.DeviceInvalidException | AuthenticationInputException.DeviceUnregisteredException e) {
                    this.channelHandlerContext.fireExceptionCaught(e);
                    e.printStackTrace();
                    return null;
                }
            }
            this.channelHandlerContext.fireExceptionCaught(new AuthenticationExpirationException());
            return null;
        });
    }

    private void checkDeviceValidation(Device device, boolean ignoreNew) throws AuthenticationInputException.DeviceInvalidException, AuthenticationInputException.DeviceUnregisteredException {
        //Register client device
        Device.Status devStatus = DeviceService.check(device);
        if(Device.Status.INVALID == devStatus) {
            throw new AuthenticationInputException.DeviceInvalidException();
        }
        else if(!ignoreNew && Device.Status.NEW == devStatus) {
            throw new AuthenticationInputException.DeviceUnregisteredException();
        }
    }

    private String createAndSaveAuthenticatedToken(Member authenticated) throws Exception {
        long currentTime = Calendar.getInstance().getTimeInMillis();
        //pattern: userid:tenantid:timestamp:lifetime
        StringBuilder sb = new StringBuilder(String.valueOf(authenticated.getId()));
        sb.append(":");
        sb.append(authenticated.getTenantId());
        sb.append(":");
        sb.append(currentTime);
        sb.append(":");
        sb.append(SESSION_EXPIRATION);
        String plainToken = sb.toString();

        String token = TokenGenerator.generate(plainToken, UserTokenStorage.getInstance().tokenKey());

        UserTokenStorage tokenStorage = UserTokenStorage.getInstance();
        tokenStorage.storeToken(authenticated, token, currentTime);
        return token;
    }

    private void doSomething(Callable<Boolean> localOrRemote){
        Future<Boolean> result = BusiProcessing.authPool.submit(localOrRemote);
        try {
            result.get(AUTH_TIMEOUT, TimeUnit.MILLISECONDS);
        }
        catch (Exception e) {
            result.cancel(true);
            e.printStackTrace();
            channelHandlerContext.fireExceptionCaught(new BusiTimeoutException());
        }
    }

    public static class UserTokenStorage extends RedisStorage<String, String>{
        private static UserTokenStorage storage = new UserTokenStorage();
        private PrivateKey privateKey;
        private UserTokenStorage() {
            super();
            try {
                init();
            }
            catch (InvalidKeyException e) {
                e.printStackTrace();
            }
        }

        public static UserTokenStorage getInstance() {
            return storage;
        }

        private void init() throws InvalidKeyException {
            String tint = AccessBusiProcessing.springContext.getBean("tokenTint", String.class);
            privateKey = RSAPrivateCrtKeyImpl.newKey(Base64.getDecoder().decode(tint));
        }

        private StatefulRedisConnection<String, String> getConnection() { return super.connection;}

        public PrivateKey tokenKey() {
            return privateKey;
        }

        public void storeToken(Member authenticated, String token, long timestamp) {
            String recordName = makeRedisRecordName4Token(authenticated.getTenantId(), String.valueOf(authenticated.getId()));

            StatefulRedisConnection<String, String> connection = getConnection();
            RedisAsyncCommands<String, String> asyncCommands = connection.async();
            asyncCommands.hset(recordName, "token", token);
            asyncCommands.hset(recordName, "name", authenticated.getName());
            asyncCommands.hset(recordName, "account", authenticated.getAccountName());
            asyncCommands.expireat(recordName, timestamp + SESSION_EXPIRATION);
        }

        public void retrieveMember(String tenantId, String userId, Callback<Map<String, String>, Member> verify) {
            String recordName = makeRedisRecordName4Token(tenantId, userId);
            StatefulRedisConnection<String, String> connection = getConnection();
            RedisAsyncCommands<String, String> asyncCommands = connection.async();
            asyncCommands.hgetall(recordName).thenAccept(verify::call);
        }

        public void removeMember(String tenantId, String userId) {
            String recordName = makeRedisRecordName4Token(tenantId, userId);
            StatefulRedisConnection<String, String> connection = getConnection();
            RedisAsyncCommands<String, String> asyncCommands = connection.async();
            asyncCommands.expireat(recordName, 1L);
        }

        private String makeRedisRecordName4Token(String tenantId, String userId) {
            StringBuilder sb = new StringBuilder("user.");
            sb.append(tenantId);
            sb.append(".");
            sb.append(userId);
            return sb.toString();
        }

        @Override
        public String storageUrl() {
            return AccessBusiProcessing.springContext.getBean("tokenRedisUrl", String.class);
        }

        @Override
        public Integer storageDbNumber() {
            return AccessBusiProcessing.springContext.getBean("tokenRedisDb", Integer.class);
        }

        @Override
        public Integer storageConnectionTimeout() {
            return AccessBusiProcessing.springContext.getBean("tokenRedisTimeout", Integer.class);
        }

        @Override
        public String makeKey(String entity) {
            return null;
        }

        @Override
        public void storeEntity(String key, String field, String entity) {
        }

        @Override
        public String retrieveEntity(String key, String field) {
            return null;
        }
    }
}
